Java并发中的锁

1. 并发控制和锁

在多处理器的时代,程序设计中经常采用多线程以充分利用处理器的性能。在多线程环境下,由于存在共享变量、共享资源等情况,因此有时候需要对多线程的并发访问进行控制。

同很多并发控制的问题类似(例如数据库的并发控制),程序中的并发控制也会使用到例如加悲观锁、乐观锁、多版本视图等技术来完成并发控制(或者称为多线程同步)。因此谈到并发控制,基本上会涉及到锁的概念,而涉及到锁的问题也基本是属于并发控制问题的范畴。

2. Java中的锁

Java中涉及到很多锁的概念,而涉及到的使用层次也不同,因此这里做一个简单的总结。

  1. 内置锁/隐式锁

    Java的每一个对象都有一个monitor,且这个monitor每一次仅能被一个线程所拥有,这就是内置锁或者叫隐式锁。内置锁的获取、释放通常是如下的范式写的:

    1
    2
    3
    synchronized(obj) {
    //当线程获取到obj的内置锁--monitor时,线程会进入到此代码块
    }

    释放内置锁:

    1
    obj.wait();//当前线程放弃obj对象上的内置锁

    或者退出synchronized代码块,也会自动释放获取的内置锁。

  2. 显式锁

    顾名思义,显式锁是显式定义的锁。例如并发工具包的Lock接口下的一些实现类。

    内置锁在Javasynchronized关键字的配合下使用起来十分的简单,但是简答的预定义的东西往往缺乏灵活性,因此为了补充内置锁,显示锁提供了一些额外的特性例如:可轮询可超时可中断锁等。这些特性在实际的编程中提供着很大的灵活性。

    Lock类的实现类常见的主要是ReentrantLock类。

    该类提供了几个重要的方法:

    • lock() 语义同synchronized
    • tryLock() 提供了可超时的特性,在某些情况下可以通过该特性避免死锁的发生
    • lockInterruptibly() throws InterruptedException 在获取锁失败被阻塞的时候可被中断,而采用synchronized获取内置锁的时候,无法被中断

  3. 可重入锁(Reentrant Lock)

    可重入锁指的是已经获取了某个锁的线程去尝试再一次该锁的时候,是可以直接获取到的,而不会阻塞。

    可重入锁避免了如下的死锁情况的产生:

    1
    2
    3
    4
    5
    6
    synchronized void get() {
    set();
    }
    synchronized void set() {

    }

    如果锁不可重入,那么当线程A获取到了“保护”get方法的锁时,那么再进入set方法的时候,会无限期阻塞。而此时,除了线程A,没有任何线程拥有该锁,因此线程A相相等于握着锁去等锁,首尾相连形成死锁了。

  4. 读写锁(Read Write Lock)

    通常的锁都为互斥锁,大多数被共享的变量都是由这种互斥锁保护。一个时刻只能有一个线程在访问该变量。这个在该变量读多写少的情况下显然效率不高。因为读读不需要并发控制,而读写、 写写才需要并发控制。那么显然应该同数据库的并发控制加锁的策略一样,应该提供两种锁,一个是共享锁(读锁)、另一个是互斥锁(写锁),当读取变量的时候,主需要获取共享锁,而写变量的时候才去获取互斥锁。

    Java并发包中提供了常用的ReentrantReadWriteLock锁,该锁提供了读锁、写锁、以及锁降级等特性。

    • readLock() 返回该读写锁对应的读锁
    • writeLock() 返回该读写锁对应的写锁

    当线程获取写锁的时候,如果该读写锁的读锁、写锁被其他线程占有,则该线程获取锁失败;

    当线程获取读锁的时候,如果没有线程持有写锁,则获取读锁成功;否则,获取读锁失败;

    读写所允许锁降级:当一个线程持有写锁的时候,可以直接降级为读锁,而不支持锁升级,因为锁升级会可能会引发死锁(当两个持有读锁的线程,同时进行锁升级,那么这两个线程都不会释放自己的读锁,从而发生死锁)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    private ReadWriteLock lock = new ReentrantReadWriteLock(true);
    private Lock r = lock.readLock(), w = lock.writeLock();
    ...
    w.lock();
    try {
    sb.append(append); //降级为read lock
    r.lock();
    } finally {
    w.unlock();//still hold read lock
    }
    try {
    ...
    } finally {
    r.unlock();
    }
    ...

  5. 偏向锁(Biased Lock)

    偏向锁是JDK1.6引入的一项锁优化,指的是偏向锁会偏向第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要同步。在某些情况下,锁不存在多线程竞争的情况,而总是由同一线程在获取、释放、获取、释放。因此,引入了偏向锁,让此种情况下的锁获取的代价变小,偏向锁可以提高带有同步但无竞争的程序性能。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public class BiasLockDemo {

    public static void main(String[] args) {
    // TODO Auto-generated method stub
    long t = System.currentTimeMillis();
    List<Integer> list = new Vector<>();//选择Vector是由于其add方法是synchronized修饰的;
    for (int i = 0; i < 1000_0000; i++) {
    list.add(i);
    }
    System.out.println("cost: " + (System.currentTimeMillis() - t) + "ms");
    }
    }

    -XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0开启偏向锁后,运行时间:

    1
    cost: 340ms

    -XX:-UseBiasedLocking禁用偏向锁后,运行时间:

    1
    cost: 519ms
  6. 公平锁/非公平锁

    公平锁是指多个线程在等待同一个锁时,必须按照申请锁的先后顺序来获得锁。

    非公平锁是指多个线程在等待同一个锁时,是按按照不确定的顺序来选择某一个线程获取锁。

    通常来讲,公平锁的性能低于非公平锁,但是公平锁可以解决线程饥饿的问题

    Java中可以使用new ReentrantLock(true)构造得到公平锁,而synchronized则提供的内置锁是非公平的。

    ps: Java中提供的显式锁一般都提供Fair和Non-Fair模式,但是即便是公平模式也会提供一些允许插队(barging)的方法允许线程先于等待在前面的线程得到锁。

  7. 悲观锁/乐观锁

    悲观锁:主要的并发控制策略之一,假设冲突总是发生,如果不采取同步措施,例如对共享的变量或者资源加锁,那么肯定会出现问题,类似于事前预防。因此无论共享的数据无论是是否出现竞争冲突,都会对它进行正确的同步。

    乐观锁:和悲观锁不一样,乐观并发控制策略先进行操作,如果操作的数据没出现竞争,那么操作成功;如果操作的数据出现竞争,那么再进行一些后续的弥补操作(常见的就是不断的重试、或者重试数次返回失败信息),类似事后弥补,实现乐观并发控制策略有多种常见的方式:

    • CAS
    • 时间戳
    • 版本号

    存在即合理,悲观锁和乐观锁都有其应用的场景,当数据争用、冲突发生频繁的场景,悲观锁较适合;而数据争用、冲突不频繁的场景,乐观锁则更适合。

  8. 自旋锁(Spinning Lock)

    互斥同步的时候,当线程获取锁失败的时候,通常会进入阻塞状态,java线程和操作系统线程是一一对应的,挂起和恢复线程操作需要由用户态转入核心态完成,这些操作耗时、耗资源。但是某些情况下,某一个线程只会将锁独占很短时间,或者是说很快 便完成了同步代码块的执行,因此其它线程为了这点时间选择将自己挂起、恢复十分没有必要。因此,特别是在多处理环境下,可以让后面请求独占锁失败的线程,进行自旋(忙循环)一会儿,而不是阻塞挂起线程。

    自旋锁的引入是为了解决锁被独占的时间很短的情况下,避免线程被挂起-恢复带来的overhead,因此当锁独占的时间本来就很长的,这种锁便没有存在的意义了。

    JVM中可以通过参数:

    -XX:+UseSpinning开启自旋锁功能;JDK1.6默认是开启的。

    -XX:PreBlockSpin配置每次自旋的次数,默认是10次;

3. 死锁和活锁

3.1 死锁

并发中问题中的死锁最经典莫过于哲学家就餐问题,死锁常常发生在系统高负载环境下,多线程竞争某一共享数据的情况下。当线程A持有锁L的时候同时,线程B持有锁M并尝试获得L,那么这两个线程将永远等待下去。这种情况就是最简单的死锁形式,多个线程由于存在环路的依赖关系而永远的等待下去。

死锁发生最常见的的根本原因就是:多个线程存在环路的依赖关系

比如A等待B,B等待C,C等待D, …, Z等待A,则A间接的等待A,形成环路,发生死锁。

环路的产生具体有如下几种情况:

  • 锁顺序死锁:加锁的顺序不一致导致的死锁;
  • 动态的锁顺序死锁:方法内部加锁顺序是一致的,但是由于锁被参数化了,因此调用该方法时,锁的顺序取决于方法调用者传来的参数,因此也会动态的产生锁顺序死锁。
  • 协作对象之间发生的死锁
  • 资源死锁 例如:线程A持有数据库连接D1并等待D2,而线程B持有数据库连接D2,等待D1则A、B之间出现死锁

解决死锁问题通常有两个角度来解决,死锁避免和死锁解除,一个属于事前预防,另一个是事后弥补;

数据库系统中,为避免死锁,有一个著名的两阶段加锁协议,同时,事务管理器可以通过环路判断死锁的存在,并取消一个代价小的事务以达到死锁的解除。

Java没有数据库事务管理器那么强大,Java中也有一些方法可以避免死锁,但是当死锁发生的时候,除了重启应用别无他法。

Java中的死锁避免:

  • 加锁顺序保持相同(synchronized提供的内置锁只能通过此种方式来避免死锁的发生)
  • 采用可轮询的、可超时的锁(显式锁Lock提供tryLock(long timeout)轮询和超时的特性,因此不会无限的等待下去,当超时的时候,程序可以简单的重试,或者放弃获取该锁,释放已有的锁。同时这种方式通过引入随机因素也可以有限的解决活锁的问题)

3.2 活锁

死锁是形成死锁的线程全部处于无限等待状态,而活锁则是线程不断的重复执行相同的操作,而且总是失败。就相当于线程在执行一个循环的操作序列,周而复始,无穷无尽,导致系统的状态整体停滞不前。

最形象的例子便是:

两个过于礼貌的人甲乙,相向走在一个狭窄的巷子里面,甲和乙同时让对方先走,然后甲乙同时准备接受对方的谦让自己先走,然后两人又同时让对方先走…,如此循环往复,两人没有等待,始终处于活动状态,但是两人始终都无法通过巷子。

同样的类似活锁的例子就是,以太网的共享介质传输信息时,也会出现活锁的问题,以太网技术采用了一种叫做载波多路访问-冲突检测(CSMA-CD)的技术,该技术引入了一些随机因素来避免活锁。

同样的,解决活锁的问题,可以在重试机制中以引入随机性,这样可以有效的避免活锁问题。

4.reference

[1]. Java并发编程实践

[2]. 深入理解JVM虚拟机

[3]. http://www.importnew.com/19472.html

[4]. https://www.cnblogs.com/qifengshi/p/6831055.html